20장. Generic
상자 하나를 떠올려 봅시다.
어떤 상자는 사과만 담고,
어떤 상자는 책만 담습니다.
그런데 “무엇이든 담을 수 있는 상자“를 만들되,
“이 상자에는 사과만 담기로 한다“처럼
담을 종류를 나중에 정할 수 있다면 어떨까요?
제네릭(Generic)이 바로 그런 도구입니다.
타입을 미리 못 박지 않고,
“쓸 때 정하는” 방법입니다.
이 장에서는 제네릭으로 클래스와 함수를 만드는 법,
그리고 타입에 조건을 거는 법을 배웁니다.
마지막에는 백엔드에서 자주 쓰는
API 응답 객체를 제네릭으로 만들어 봅니다.
이 장부터는 난이도가 한 단계 높은 심화 파트(7부)입니다.
처음 읽을 때는 어렵게 느껴질 수 있습니다.
지금 다 이해하지 못해도 괜찮습니다.
일단 가볍게 훑고 넘어간 뒤,
실제로 필요할 때 다시 돌아와 읽어도 충분합니다.
20.1 Generic Class
타입을 나중에 정하는 상자
먼저 제네릭이 없는 세상을 상상해 봅시다.
“값 하나를 담는 상자“를 만들고 싶습니다.
그런데 담을 값의 종류마다
상자를 따로 만들어야 한다면 어떨까요?
class IntBox(val value: Int) // 정수 전용 상자
class StringBox(val value: String) // 문자열 전용 상자
하는 일은 똑같은데,
타입만 다른 상자를 계속 만들게 됩니다.
이건 너무 번거롭습니다.
이럴 때 제네릭을 씁니다.
class Box<T>(val value: T)
여기서 T가 바로 제네릭의 핵심입니다.
T는 “아직 정해지지 않은 타입“을 뜻하는 이름표입니다.
이런 이름표를 타입 파라미터(type parameter)라고 부릅니다.
파라미터(parameter)라는 말이 붙은 이유가 있습니다.
함수에 값을 나중에 넘기듯,
클래스에 타입을 나중에 넘기기 때문입니다.
값을 넘기는 자리가 함수의 파라미터라면,
타입을 넘기는 자리가 바로 타입 파라미터입니다.
만든 상자 사용하기
이제 이 상자를 써 봅시다.
상자 이름 뒤 꺾쇠(< >) 안에
담을 타입을 적어 주면 됩니다.
val intBox = Box<Int>(10)
val stringBox = Box<String>("안녕")
println(intBox.value) // 10
println(stringBox.value) // 안녕
Box<Int>라고 적는 순간
그 상자의 T는 Int로 정해집니다.Box<String>이라면 T는 String이 됩니다.
하나의 클래스로
정수 상자도, 문자열 상자도 만든 것입니다.
타입 추론도 그대로 동작합니다.
값만 봐도 타입을 알 수 있다면
꺾쇠 부분을 생략할 수 있습니다.
val intBox = Box(10) // Box<Int>로 추론됨
val stringBox = Box("안녕") // Box<String>으로 추론됨
타입 파라미터 이름 규칙
타입 파라미터 이름은 아무렇게나 지어도 됩니다.
하지만 관례처럼 자주 쓰는 대문자 한 글자가 있습니다.
| 이름 | 주로 쓰는 곳 |
|---|---|
| T | 타입(Type) 하나 |
| E | 목록의 원소(Element) |
| K | 맵의 키(Key) |
| V | 맵의 값(Value) |
꼭 이 글자를 써야 하는 것은 아닙니다.
다만 다른 개발자와 코드를 나눌 때
이 관례를 따르면 서로 알아보기 쉽습니다.
타입 파라미터는 여러 개도 가능합니다.
쉼표로 나열하면 됩니다.
class Pair<A, B>(val first: A, val second: B)
val pair = Pair("나이", 20)
println(pair.first) // 나이
println(pair.second) // 20
여기서는 A는 String으로,B는 Int로 각각 정해졌습니다.
자바의 제네릭과 비교하기
자바에도 제네릭이 있습니다.
자바를 조금 아는 분이라면 낯익을 것입니다.
// 자바
class Box<T> {
private final T value;
Box(T value) { this.value = value; }
T getValue() { return value; }
}
// 코틀린
class Box<T>(val value: T)
기본 생각은 똑같습니다.<T>로 타입을 나중에 정한다는 점에서 다르지 않습니다.
다만 코틀린은 생성자와 속성을 한 줄로 적을 수 있어
훨씬 짧습니다.
(이 짧은 문법은 이미 클래스를 다룬 장에서 배웠습니다.)
20.2 Generic Function
함수에도 타입을 넘긴다
제네릭은 클래스에만 쓰는 것이 아닙니다.
함수 하나에도 적용할 수 있습니다.
예를 들어 “값을 그대로 돌려주는 함수“를 만들어 봅시다.
어떤 타입이 들어오든
그 타입 그대로 돌려주고 싶습니다.
fun <T> echo(value: T): T {
return value
}
여기서 위치를 잘 봐 둡시다.
타입 파라미터 <T>가fun과 함수 이름 사이에 놓여 있습니다.
fun <T> 함수이름(파라미터: T): T
▲
여기에 타입 파라미터를 선언
이 <T> 선언이 있어야
파라미터와 반환 타입에서 T를 쓸 수 있습니다.
사용할 때는 이렇게 합니다.
val a = echo<Int>(10) // T는 Int
val b = echo<String>("hi") // T는 String
println(a) // 10
println(b) // hi
함수도 타입 추론이 됩니다.
넘긴 값으로 타입을 알 수 있으니
꺾쇠를 생략해도 됩니다.
val a = echo(10) // T가 Int로 추론됨
val b = echo("hi") // T가 String으로 추론됨
실제로 쓸모 있는 예
좀 더 그럴듯한 예를 봅시다.
“목록의 첫 번째 값을 돌려주는 함수“입니다.
목록 안에 무엇이 들었든
그 타입 그대로 돌려주고 싶습니다.
fun <T> firstOrNull(list: List<T>): T? {
if (list.isEmpty()) {
return null
}
return list[0]
}
val numbers = listOf(1, 2, 3)
val words = listOf("가", "나", "다")
println(firstOrNull(numbers)) // 1
println(firstOrNull(words)) // 가
정수 목록을 넣으면 정수가,
문자열 목록을 넣으면 문자열이 나옵니다.
함수 하나로 모든 타입의 목록을 처리한 것입니다.
이것이 제네릭 함수의 힘입니다.
반환 타입에 붙은 ?가 궁금할 수 있습니다.
목록이 비어 있으면 돌려줄 값이 없어null을 돌려주기 때문입니다.
제네릭과 null의 관계는 20.4에서 자세히 다룹니다.
20.3 Type Parameter 제한 (상한 경계)
아무 타입이나 받으면 생기는 문제
제네릭은 “어떤 타입이든” 받습니다.
그런데 이 자유로움이 가끔 문제가 됩니다.
예를 들어 “두 값 중 큰 값을 돌려주는 함수“를 만든다고 합시다.
fun <T> max(a: T, b: T): T {
if (a > b) { // 오류!
return a
}
return b
}
이 코드는 오류가 납니다.
왜냐하면 T가 “아무 타입“이기 때문입니다.
T에 어떤 타입이 올지 모르는데,
그 타입이 >로 크기 비교를 할 수 있는지
코틀린은 알 방법이 없습니다.
담을 수 있는 종류를 좁혀 줘야 합니다.
상한 경계로 조건 걸기
이럴 때 타입 파라미터에 조건을 겁니다.
“이런 타입만 받겠다“고 제한하는 것입니다.
T 뒤에 콜론(:)을 쓰고
조건이 될 타입을 적습니다.
fun <T : Comparable<T>> max(a: T, b: T): T {
if (a > b) {
return a
}
return b
}
여기서 Comparable은
“크기를 비교할 수 있는 타입“을 뜻합니다.Int, String, Double 같은 타입이 여기에 해당합니다.
이렇게 T : Comparable<T>라고 적으면
“크기를 비교할 수 있는 타입만 T로 받겠다“는 뜻이 됩니다.
이 조건을 상한 경계(upper bound)라고 부릅니다.
말이 어렵지만 뜻은 간단합니다.
상한 경계란,
“T는 적어도 이 타입은 되어야 한다“는 자격 조건입니다.
이제 함수 안에서 > 비교를 안심하고 쓸 수 있습니다.T가 반드시 비교 가능한 타입이라고
약속되어 있기 때문입니다.
println(max(3, 7)) // 7
println(max("가", "나")) // 나
비유로 이해하기
상한 경계는 놀이기구의 키 제한과 비슷합니다.
키가 120cm 이상인 사람만 탈 수 있다.
이 조건이 있으면
탈 수 있는 사람은 좁아지지만,
대신 “탄 사람은 모두 120cm 이상“이라고
믿을 수 있습니다.
제네릭도 똑같습니다.
조건을 걸면 받을 수 있는 타입은 좁아지지만,
그 대신 그 타입이 무엇을 할 수 있는지
확실히 알게 됩니다.
조건이 여러 개일 때 (where)
조건을 두 개 이상 걸고 싶을 때도 있습니다.
“비교도 가능하고, 복제도 가능한 타입“처럼요.
이럴 때는 where를 사용합니다.
fun <T> process(value: T): T
where T : Comparable<T>,
T : Cloneable {
// T는 비교도 되고 복제도 되는 타입
return value
}
where는 함수 선언 끝에 붙여서
여러 조건을 쉼표로 나열합니다.
조건이 하나뿐이라면
앞에서 본 T : Comparable<T> 방식이 더 간단합니다.
조건이 둘 이상일 때만 where를 떠올리면 됩니다.
정리하면 이렇습니다.
- 조건 하나 :
<T : 타입> - 조건 여럿 :
<T> ... where T : 타입1, T : 타입2
20.4 Nullable Generic
타입 파라미터와 null
3부에서 배웠듯,
코틀린은 null을 아주 조심스럽게 다룹니다.
타입 뒤에 ?가 붙어야만 null이 들어올 수 있었습니다.
그런데 제네릭에서는 한 가지 헷갈리는 점이 있습니다.
타입 파라미터 T 자체가
이미 null을 허용할 수도 있다는 점입니다.
무슨 말인지 코드로 봅시다.
class Box<T>(val value: T)
val box = Box<String?>(null) // 가능!
println(box.value) // null
T에 String?을 넣었습니다.
그러니 value는 null을 담을 수 있게 됩니다.
즉, 아무 표시가 없는 T라도T에 String? 같은 nullable 타입이 들어오면
그 안에는 null이 담길 수 있습니다.
T의 기본 상한은 Any?
여기서 한 걸음 더 들어가 봅시다.
class Box<T>(val value: T)
이렇게 아무 조건 없이 T라고만 적으면,
사실 코틀린은 속으로 이렇게 이해합니다.
class Box<T : Any?>(val value: T)
Any?는 2장에서 잠깐 봤습니다.
“모든 타입의 최상위이면서, null도 될 수 있는 타입“입니다.
그래서 조건 없는 T의 상한 경계는 Any?입니다.
즉, 기본적으로 T에는
nullable 타입까지 들어올 수 있는 것입니다.
조건을 안 걸면
T는Any?,
즉 null까지 포함한 “무엇이든“이 됩니다.
null을 막고 싶다면 Any로 제한
만약 “이 상자에는 절대 null을 담지 못하게” 하고 싶다면
상한 경계를 Any로 걸어 주면 됩니다.
(?가 없는 Any입니다.)
class Box<T : Any>(val value: T)
val box = Box("안녕") // 가능
// val bad = Box<String?>(null) // 오류! null 타입은 못 받음
T : Any라고 적으면T에는 null이 될 수 없는 타입만 들어옵니다.
20.3에서 배운 상한 경계를
null 제어에 활용한 것입니다.
함수 안에서 null 돌려주기
앞서 20.2에서 본 함수를 다시 봅시다.
fun <T> firstOrNull(list: List<T>): T? {
if (list.isEmpty()) {
return null
}
return list[0]
}
반환 타입이 T가 아니라 T?인 점에 주목합시다.
T가 무엇이든,T?라고 적으면
“그 타입이거나 null“이라는 뜻이 됩니다.
목록이 비어 있어 돌려줄 값이 없을 때
안전하게 null을 돌려주기 위한 장치입니다.
정리하면 이렇습니다.
T: 넘어온 타입 그대로 (null 여부는 T에 달림)T?: 그 타입이거나 null
제네릭이라고 해서 null 규칙이 사라지는 것은 아닙니다.
오히려 ?를 어디에 붙이는지를
더 신경 써서 봐야 합니다.
20.5 Generic API 응답 객체 만들기
왜 필요할까
이제 배운 것을 백엔드다운 예제에 써 봅시다.
서버는 클라이언트에게 응답을 돌려줍니다.
그런데 응답의 겉모양은 대체로 비슷합니다.
- 성공했는지 여부
- 메시지
- 실제 데이터
여기서 “실제 데이터“만
요청마다 종류가 다릅니다.
사용자 정보를 돌려줄 때도 있고,
주문 목록을 돌려줄 때도 있습니다.
바로 이 “겉모양은 같고 알맹이만 다른” 상황이
제네릭을 쓰기에 딱 좋습니다.
응답 객체 만들기
응답을 감싸는 상자를 하나 만들어 봅시다.
알맹이 데이터의 타입을 T로 둡니다.
data class ApiResponse<T>(
val success: Boolean,
val message: String,
val data: T,
)
data class는 값을 담는 데 특화된 클래스입니다.
(이미 앞 장에서 배웠습니다.)
여기서 중요한 것은 data: T 부분입니다.
알맹이의 타입을 미리 정하지 않고T로 열어 둔 것입니다.
이제 데이터 종류가 무엇이든
같은 ApiResponse로 감쌀 수 있습니다.
다양한 데이터로 채워 보기
사용자 정보와 주문 목록이
각각 아래처럼 있다고 합시다.
data class UserResponse(val id: Long, val name: String)
data class OrderResponse(val orderId: Long, val amount: Int)
이제 ApiResponse로 감싸 봅시다.
val userResult = ApiResponse(
success = true,
message = "조회 성공",
data = UserResponse(1, "홍길동"),
)
val orderResult = ApiResponse(
success = true,
message = "조회 성공",
data = listOf(
OrderResponse(100, 5000),
OrderResponse(101, 8000),
),
)
여기서 두 응답의 타입을 적어 보면 이렇게 됩니다.
ApiResponse<UserResponse>
ApiResponse<List<OrderResponse>>
첫 번째는 알맹이가 사용자 하나이고,
두 번째는 알맹이가 주문 목록입니다.
T 자리에 List<OrderResponse>처럼
그 자체가 제네릭인 타입도 들어갈 수 있습니다.
상자 안에 또 다른 상자를 넣은 셈입니다.
클래스를 한 번만 만들었는데
사용자 응답, 주문 목록 응답을
모두 표현할 수 있게 되었습니다.
성공과 실패를 함수로 나누기
실무에서는 성공 응답과 실패 응답을
더 편하게 만들도록 함수로 감싸 두기도 합니다.
여기서 제네릭 함수가 쓰입니다.
fun <T> success(data: T): ApiResponse<T> {
return ApiResponse(
success = true,
message = "성공",
data = data,
)
}
val userResult = success(UserResponse(1, "홍길동"))
// 타입: ApiResponse<UserResponse>
val orderResult = success(listOf(OrderResponse(100, 5000)))
// 타입: ApiResponse<List<OrderResponse>>
success에 넘긴 데이터의 타입에 따라
결과의 T가 자동으로 정해집니다.
20.2에서 배운 타입 추론이 그대로 동작하는 것입니다.
이렇게 제네릭을 쓰면
응답 규격은 하나로 통일하면서도
데이터 종류는 자유롭게 바꿀 수 있습니다.
겉모양은 하나로,
알맹이는 자유롭게.
이것이 백엔드에서 제네릭을 쓰는
가장 흔하고 실용적인 이유입니다.
20장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 제네릭은 타입을 미리 정하지 않고
쓸 때 정하는 방법이라는 점 class Box<T>처럼
클래스에 타입 파라미터를 다는 법fun <T> ...처럼
함수에도 제네릭을 적용하는 법- 상한 경계(
T : 타입)와where로
타입에 조건을 거는 법 - 조건 없는
T의 기본 상한은Any?이며,T : Any로 null을 막을 수 있다는 점 ApiResponse<T>처럼
실무에서 쓰는 제네릭 응답 객체를 만드는 법
제네릭은 처음에는 낯설지만,
“타입을 나중에 넘기는 파라미터“라는
한 가지 생각만 잡으면 훨씬 쉬워집니다.
아직 남은 이야기가 있습니다.
Box<String>을 Box<Any>처럼
다룰 수 있는지에 대한 문제는
변성(variance)이라고 부르며 21장에서 다룹니다.
또 실행 중에 T가 무엇인지 알아내는reified 키워드는 22장에서 이어집니다.
지금은 “타입도 넘길 수 있다“는 감각만
챙겨 두면 충분합니다.